---
title: Рисуем простую капчу
description: Напишем алгоритм для отображения текста в виде картинки с использованием библиотеки Java AWT. Символы и шрифт могут быть любыми, но для этого примера будем...
sections: [Криптография,Отрисовка шрифта,Поворот изображения]
tags: [java,awt,графика,изображение,картинка,капча]
canonical_url: /ru/2023/01/03/drawing-simple-captcha.html
url_translated: /en/2023/01/04/drawing-simple-captcha.html
title_translated: Drawing simple captcha
date: 2023.01.03
---

Напишем алгоритм для отображения текста в виде картинки с использованием библиотеки Java AWT.
Символы и шрифт могут быть любыми, но для этого примера будем использовать комбинацию заглавных
латинских букв и цифр со шрифтом *Comic Sans* — будем рисовать простую капчу для сайта или блога.

{% include picture.html id="captcha.png" src="/img/captcha.png" alt="Рисуем простую капчу" %}

Спецсимволы тоже рассмотрим, но использовать их не будем, потому что угадать спецсимволы пользователю
будет сложно с таким оформлением текста. Например, плюс `+` угадать ещё можно, а вот минус `-` или
нижнее подчёркивание `_` уже с трудом, и даже если угадаешь, тогда найти эти кнопки с трудом, особенно
на телефоне. Поэтому для капчи будем использовать комбинацию только из заглавных латинских букв и цифр.

Отрисовка спецсимволов моноширинным шрифтом: [Рисуем сердечко в консоли]({{ '/ru/2023/03/08/drawing-heart-in-console.html#text-as-picture-and-picture-as-text' | relative_url }}).

{% include heading.html text="Описание алгоритма" hash="algorithm-description" %}

Подготавливаем массив символов, состоящий из заглавных латинских букв и цифр. Затем обходим
этот массив и отрисовываем каждый символ отдельно — получаем картинку. Затем поворачиваем
картинки поочерёдно на ±35 градусов — получаем массив картинок с символами. Второй раз обходим
массив с картинками и собираем общее изображение — присоединяем картинки слева направо таким
образом, чтобы последующая картинка наезжала на предыдущую на 40% её ширины.

Почему 35 градусов? Если взять угол больше, тогда пользователю будет сложно разгадать такую
капчу. Например, буквы `N` и `Z` будут похожи друг на друга. Если взять угол меньше, то такую
капчу будет легко разгадать с помощью машинного распознавания текста.

Наложение последующей картинки на предыдущую на 40% её ширины нужно, чтобы символы располагались
очень близко или слегка касались друг друга — это также затрудняет машинное распознавание текста.

{% include heading.html text="Отрисовка шрифта" hash="font-rendering" %}

При отрисовке шрифта будем использовать сглаживание *anti-aliasing*, иначе буквы будут
с зазубренными краями. Устанавливаем изображение с поддержкой прозрачности, цвет чёрный,
шрифт *Comic Sans*.

```java
// преобразовываем строку с текстом в картинку с текстом
private static BufferedImage stringToImage(String str, Font font) {
    // контекст отображения шрифта
    FontRenderContext ctx = new FontRenderContext(font.getTransform(), true, true);
    // получаем размеры картинки с текстом при отрисовке
    Rectangle bnd = font.getStringBounds(str, ctx).getBounds();
    // создаём новое изображение с поддержкой прозрачности
    BufferedImage image = new BufferedImage(bnd.width, bnd.height, BufferedImage.TYPE_INT_ARGB);
    // включаем режим редактирования нового изображения
    Graphics2D graphics = image.createGraphics();
    // шрифт для отрисовки
    graphics.setFont(font);
    // цвет, которым будем рисовать
    graphics.setColor(Color.BLACK);
    // применяем сглаживание шрифта при отрисовке текста
    graphics.setRenderingHint( // сглаживание пикселей вдоль границы фигуры
            RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    // рисуем картинку с текстом
    graphics.drawString(str, bnd.x, -bnd.y);
    // отключаем режим редактирования
    graphics.dispose();
    // возвращаем картинку с текстом
    return image;
}
```

{% include heading.html text="Поворот изображения" hash="image-rotation" %}

При повороте изображения для сглаживания будем использовать *билинейную интерполяцию*, иначе
будет много лишних артефактов по границам изображения. По дороге пересчитываем размеры для
нового изображения.

```java
// поворачиваем картинку на заданный угол и изменяем её размеры
private static BufferedImage rotateImage(BufferedImage image, double angle) {
    // переводим градусы в радианы
    double radian = Math.toRadians(angle);
    double sin = Math.abs(Math.sin(radian));
    double cos = Math.abs(Math.cos(radian));
    // получаем размеры текущего изображения
    int width = image.getWidth();
    int height = image.getHeight();
    // вычисляем размеры нового изображения
    int nWidth = (int) Math.floor(width * cos + height * sin);
    int nHeight = (int) Math.floor(height * cos + width * sin);
    // создаём новое изображение с поддержкой прозрачности
    BufferedImage rotated = new BufferedImage(nWidth, nHeight, BufferedImage.TYPE_INT_ARGB);
    // включаем режим редактирования нового изображения
    Graphics2D graphics = rotated.createGraphics();
    // применяем сглаживание изображения при повороте
    graphics.setRenderingHint( // билинейная интерполяция
            RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
    // сдвигаем начало координат нового изображения в его центр
    graphics.translate(nWidth / 2, nHeight / 2);
    // поворачиваем новое изображение вместе с его системой координат
    graphics.rotate(radian);
    // помещаем текущее изображение в новое, чтобы их центры совпали
    graphics.drawImage(image, -width / 2, -height / 2, null);
    // отключаем режим редактирования
    graphics.dispose();
    // возвращаем новое изображение
    return rotated;
}
```

{% include heading.html text="Рисуем простую капчу" hash="drawing-simple-captcha" %}

Обходим массив символов, отрисовываем и поворачиваем каждый символ в отдельности, по дороге 
вычисляем размеры для общего изображения. Создаём общее изображение и после этого ещё раз
обходим массив картинок и дорисовываем их по одной слева направо к общему изображению.
Возвращаем пару объектов: текстовое значение капчи и картинку с символами.

```java
// отрисовываем массив символов, поворачиваем их и объединяем результаты
private static Map.Entry<String, BufferedImage> drawSimpleCaptcha(String[] symbols)
        throws IOException, FontFormatException {
    Font font = Font // устанавливаем файл шрифта
            .createFont(Font.TRUETYPE_FONT, new File("ComicSansMS.ttf"))
            // устанавливаем стиль и размер шрифта
            .deriveFont(Font.BOLD, 32);
    // размеры итогового изображения
    int width = 0, height = 0;
    // подготавливаем массив картинок
    BufferedImage[] images = new BufferedImage[symbols.length];
    // обходим массив символов, получаем картинки
    // и вычисляем размеры итогового изображения
    for (int i = 0; i < symbols.length; i++) {
        if (i % 2 == 0) // отрисовываем символы и поворачиваем изображения
            images[i] = rotateImage(stringToImage(symbols[i], font), 35);
        else
            images[i] = rotateImage(stringToImage(symbols[i], font), -35);
        // размеры картинки с текущим символом
        int h = images[i].getHeight(), w = images[i].getWidth();
        // высота самого большого символа
        height = Math.max(height, h);
        // последующий символ будем сдвигать на 40% ширины предыдущего
        if (i < symbols.length - 1)
            width += w * 6 / 10; // берём 60% ширины текущего символа
        else // ширину последнего символа берём целиком
            width += w;
    }
    // создаём новое изображение с поддержкой прозрачности
    BufferedImage captcha = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
    // включаем режим редактирования нового изображения
    Graphics2D graphics = captcha.createGraphics();
    // обходим массив картинок и дорисовываем их к общему изображению слева направо
    for (BufferedImage image : images) {
        // отрисовываем текущий символ в начале координат
        graphics.drawImage(image, 0, 0, null);
        // сдвигаем начало координат на 60% ширины текущего символа
        graphics.translate(image.getWidth() * 6 / 10, 0);
    }
    // отключаем режим редактирования
    graphics.dispose();
    // возвращаем пару объектов: текстовое значение капчи и картинку с символами
    return Map.entry(String.join("", symbols), captcha);
}
```

{% capture collapsed_md %}
```java
// метод для внешних вызовов, возвращает случайную комбинацию из 5 символов
public static Map.Entry<String, BufferedImage> drawSimpleCaptcha()
        throws IOException, FontFormatException {
    return drawSimpleCaptcha(5);
}
```
```java
// метод для внешних вызовов, требуется указать длину строки
public static Map.Entry<String, BufferedImage> drawSimpleCaptcha(int length)
        throws IOException, FontFormatException {
    // получаем случайную комбинацию символов указанной длины
    String[] symbols = getRandomString(length);
    return drawSimpleCaptcha(symbols);
}
```
```java
// получаем случайную комбинацию заглавных латинских букв и цифр
private static String[] getRandomString(int length) {
    String[] symbols = new String[length];
    Random random = new Random();
    for (int i = 0; i < length; i++) {
        // 26 заглавных букв и 10 цифр
        int rnd = random.nextInt(36);
        if (rnd < 26) // буквы [A..Z]
            symbols[i] = Character.toString('A' + rnd);
        else // цифры [0..9]
            symbols[i] = Character.toString('0' + rnd - 26);
    }
    return symbols;
}
```
{% endcapture %}
{%- include collapsed_block.html summary="Дополнительные методы" content=collapsed_md -%}

{% include heading.html text="Тестирование и запуск" hash="testing-n-launching" %}

Алгоритм получился универсальный — отрисовать можно почти любую строку и почти любым шрифтом,
но с длинным списком исключений, связанных с диапазонами символов юникода и типами шрифтов.
Вариантов много, тестировать долго, а упрощенная модель меня вполне устраивает.

В завершение этого примера для визуализации нарисуем строку: `SIMPLE+CAPTCHA+1+1`.

```java
// запускаем программу и выводим результат
public static void main(String[] args) throws IOException, FontFormatException {
    String[] symbols = "simple+captcha+1+1".toUpperCase().split("");
    // Map.Entry<String, BufferedImage> captcha = drawSimpleCaptcha(18);
    Map.Entry<String, BufferedImage> captcha = drawSimpleCaptcha(symbols);
    // сохраняем картинку в файл, текст в консоль
    ImageIO.write(captcha.getValue(), "png", new File("captcha.png"));
    // System.out.println(captcha.getKey());
}
```

Картинку из этого кода см. выше: [captcha.png](#captcha.png).
